Skip to content

Feature(cli): Add --sync to prevent AI agents from breaking the host files#4429

Merged
jandubois merged 1 commit intolima-vm:masterfrom
unsuman:feat/rsync
Jan 30, 2026
Merged

Feature(cli): Add --sync to prevent AI agents from breaking the host files#4429
jandubois merged 1 commit intolima-vm:masterfrom
unsuman:feat/rsync

Conversation

@unsuman
Copy link
Contributor

@unsuman unsuman commented Dec 4, 2025

This PR adds a --sync flag to the limactl shell command that allows users to safely run AI commands inside VMs by syncing the host's working directory to the guest and optionally syncing changes back to the host after confirmation. Also added cleanup logic to remove the guest's synced workdir after user decision, ensuring no leftover files in the VM.

How to test

$ limactl start --mount-none
$ cd ~/some-project
$ limactl shell --sync <AI Agent Command>
OR
$ limactl shell --sync # simply shell into the instance and make changes
INFO[0000] Syncing host current directory(/Users/ansumansahoo/temp) to guest instance... 
INFO[0000] Successfully synced host current directory to guest(~/synced-workdir/temp) instance. 
ansumansahoo@lima-default:~/synced-workdir/temp$ vi source_dir/initials.txt 
ansumansahoo@lima-default:~/synced-workdir/temp$ exit
logout
? ⚠️ Accept the changes?  [Use arrows to move, type to filter]
  Yes
  No
> View the changed contents

When the program prompts the user to view the changes, a rsync is performed. This copies the guest synced directory to a temporary directory on the host. This allows diff to be used and the user to see a detailed list of changes.
Fixes #3711

shellCmd.Flags().Bool("reconnect", false, "Reconnect to the SSH session")
shellCmd.Flags().Bool("preserve-env", false, "Propagate environment variables to the shell")
shellCmd.Flags().Bool("start", false, "Start the instance if it is not already running")
shellCmd.Flags().Bool("sync-host-workdir", false, "Copy the host working directory to the guest to run AI commands inside VMs (prevents AI agents from breaking the host files)")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • For consistency with --mount ., probably this should be like --sync .
  • The flag is not really specific to AI commands. Use case with AI should be mentioned in https://lima-vm.io/docs/examples/ai/ though.


const (
rsyncMinimumSrcDirDepth = 4 // Depth of "/Users/USER" is 3.
guestSyncedWorkdir = "~/synced-workdir"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any reason that we cannot just use the same path as the host dir?
Is that for avoiding conflicts with mounts?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any reason that we cannot just use the same path as the host dir?

Yes, issue with rsync because it only tries to create the base of the path and not the full path so using for example ansumansahoo@127.0.0.1:~/Users/ansumansahoo/Documents/GOLANG/lima as a destination path will result into an error rsync: [Receiver] mkdir "/home/ansumansahoo.linux/Users/ansumansahoo/Documents/GOLANG/lima" failed: No such file or directory (2)

Or do you mean to use only ansumansahoo@127.0.0.1:~/lima regarding this context?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it only tries to create the base of the path and not the full path

Why not run mkdir -p

}

for {
ans, err := uiutil.Select(message, options)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rsync can be slow and may eat up the host disk space, so this prompt should be shown before running rsync to the tmp dir

return
case 2: // View the changed contents
diffCmd := exec.CommandContext(ctx, "diff", "-ru", "--color=always", hostCurrentDir, filepath.Join(hostTmpDest, filepath.Base(hostCurrentDir)))
lessCmd := exec.CommandContext(ctx, "less", "-R")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Respect $PAGER

if err != nil {
return fmt.Errorf("failed to get sync-host-workdir flag: %w", err)
} else if syncHostWorkdir && len(inst.Config.Mounts) > 0 {
return errors.New("cannot use `--sync-host-workdir` when the instance has host mounts configured, use `--mount-none` to disable mounts")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return errors.New("cannot use `--sync-host-workdir` when the instance has host mounts configured, use `--mount-none` to disable mounts")
return errors.New("cannot use `--sync-host-workdir` when the instance has host mounts configured, start the instance with `--mount-none` to disable mounts")

}
} else {
case syncHostWorkdir:
changeDirCmd = fmt.Sprintf("cd %s/%s", guestSyncedWorkdir, filepath.Base(hostCurrentDir))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sure to quote the path, as it may contain spaces and quote symbols

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Such paths should be tested in bats)

}
rsyncCmd := exec.CommandContext(ctx, "rsync", rsyncArgs...)
rsyncCmd.Stdin = os.Stdin
rsyncCmd.Stdout = os.Stdout
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

destination,
}
rsyncCmd := exec.CommandContext(ctx, "rsync", rsyncArgs...)
rsyncCmd.Stdin = os.Stdin
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For what ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NVM, it was a mistake!

@unsuman
Copy link
Contributor Author

unsuman commented Dec 5, 2025

Left with adding bats test and docs.

@unsuman
Copy link
Contributor Author

unsuman commented Dec 9, 2025

How can I test existing bats test files?
I tried to run make bats first and it always results into 0 tests and 0 failures:

➜  lima2 git:(feat/rsync) ✗ ./hack/bats/lib/bats-core/bin/bats ./hack/bats/tests/list.bats

0 tests, 0 failures

➜  lima2 git:(feat/rsync) ✗ ./hack/bats/lib/bats-core/bin/bats --version
Bats 1.12.0

➜  lima2 git:(feat/rsync) ✗ lm -v
limactl version 2.0.1-122-g997e1287.m

[EDIT]: Ran a sample bats test and it works:

#!/usr/bin/env bats
@test "simple test" {
    run echo "hello"
    [ "$status" -eq 0 ]
    [ "$output" = "hello" ]
}
➜  lima2 git:(feat/rsync) ✗ ./hack/bats/lib/bats-core/bin/bats test-simple.bats                                                    
test-simple.bats
 ✓ simple test

1 test, 0 failures

@unsuman unsuman changed the title Feature(cli): Add sync-host-workdir to prevent AI agents from breaking the host files Feature(cli): Add --sync to prevent AI agents from breaking the host files Dec 9, 2025
@unsuman
Copy link
Contributor Author

unsuman commented Dec 10, 2025

always results into 0 tests and 0 failures

This was due to the fact that my Bash version which comes out of the box on MacOS was 3.2 which was silently failing because Bash 3.2 does not support associative arrays(introduced in 4.0). Had to upgrade it using brew and now it works!

declare -A -g TEST_CONTAINER_IMAGES=(

@AkihiroSuda AkihiroSuda added this to the v2.1.0 (?) milestone Dec 11, 2025
if pager == "" {
pager = "less"
}
lessCmd := exec.CommandContext(ctx, pager, "-R")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not every PAGER supports this flag

# Syncing Working Directory

The `--sync` flag for `limactl shell` enables bidirectional synchronization of your host working directory with the guest VM. This is particularly useful when running AI agents (like Claude, Copilot, or Gemini) inside VMs to prevent them from accidentally modifying or breaking files on your host system.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably needs a table to compare mount vs sync

@unsuman unsuman force-pushed the feat/rsync branch 5 times, most recently from 96e3dd1 to d501990 Compare January 4, 2026 20:41
@unsuman unsuman requested a review from AkihiroSuda January 4, 2026 21:03
return fmt.Errorf("failed to create the synced workdir in guest instance: %w", err)
}

if runtime.GOOS == "darwin" {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MacOS’s version of rsync (the latest being 2.6.9) doesn’t handle shell escaping itself. However, Linux versions of rsync (specifically 3.x) efficiently manage this. A shell-escaped path in a Linux environment caused path not found issues.
See this

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better to add a comment

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn’t it check the version, not OS?

|----------------|-------------|

The `--sync` flag for `limactl shell` enables bidirectional synchronization of your host working directory with the guest VM. This is particularly useful when running AI agents (like Claude, Copilot, or Gemini) inside VMs to prevent them from accidentally modifying or breaking files on your host system.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comparison table should be shown before the example commands

1. Create an isolated instance for AI agents which must be started without host mounts for `--sync` to work:

```bash
limactl start --name=ai-sandbox --mount-none template://default
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--name should not be needed

shellCmd.Flags().Bool("preserve-env", false, "Propagate environment variables to the shell")
shellCmd.Flags().Bool("start", false, "Start the instance if it is not already running")
shellCmd.Flags().String("sync", "", "Copy the host working directory to the guest and vice-versa upon exit")
shellCmd.Flags().Lookup("sync").NoOptDefVal = "."
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if we want NoOptDefVal.
Probably this one should be omitted from this PR, and should be revisited in a separate issue.

shellCmd.Flags().Bool("reconnect", false, "Reconnect to the SSH session")
shellCmd.Flags().Bool("preserve-env", false, "Propagate environment variables to the shell")
shellCmd.Flags().Bool("start", false, "Start the instance if it is not already running")
shellCmd.Flags().String("sync", "", "Copy the host working directory to the guest and vice-versa upon exit")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
shellCmd.Flags().String("sync", "", "Copy the host working directory to the guest and vice-versa upon exit")
shellCmd.Flags().String("sync", "", "Copy a host directory to the guest and vice-versa upon exit")

See also <https://github.com/anomalyco/opencode>.
{{% /tab %}}
```bash
limactl shell --sync default claude "Add error handling to all functions"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
limactl shell --sync default claude "Add error handling to all functions"
limactl shell --sync . default claude "Add error handling to all functions"

{{< /tabpane >}}
Or simply shell into the instance and make changes:
```bash
limactl shell --sync default
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above

// := "cd hostCurrentDir || cd hostHomeDir" if workDir == ""
var changeDirCmd string
var hostCurrentDir string
if syncDirVal != "" && syncDirVal != "." {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why need to check syncDirVal != "." here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I didn't know that filepath.Abs() translates . to cwd.

AkihiroSuda
AkihiroSuda previously approved these changes Jan 20, 2026
Copy link
Member

@AkihiroSuda AkihiroSuda left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks

Copy link
Member

@jandubois jandubois left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly looking good, but the hard-coded home directory name should be fixed.

}
} else {
case syncHostWorkdir:
destRsyncDir = fmt.Sprintf("/home/%s.linux%s", *inst.Config.User.Name, hostCurrentDir)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The home directory is configurable; don't recompute the default value:

Suggested change
destRsyncDir = fmt.Sprintf("/home/%s.linux%s", *inst.Config.User.Name, hostCurrentDir)
destRsyncDir = *inst.Config.User.Home + hostCurrentDir

There is another instance of this further down in the file.

remoteSource := fmt.Sprintf("%s:%s", *inst.Config.User.Name+"@"+inst.SSHAddress, destRsyncDir)
clean := filepath.Clean(hostCurrentDir)
parts := strings.Split(clean, string(filepath.Separator))
dirForCleanup := shellescape.Quote(fmt.Sprintf("/home/%s.linux/", *inst.Config.User.Name) + parts[1])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use User.Home instead. Also check that strings.Split() has returned at least 2 elements.

Comment on lines 400 to 403
if err := rsyncDirectory(ctx, cmd, sshCmd, remoteSource, filepath.Dir(hostCurrentDir)); err != nil {
logrus.WithError(err).Warn("Failed to sync back the changes to host")
return
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this function return an error? Why are these failures just logging a warning, and then continue as if everything is ok?

ans, err := uiutil.Select(message, options)
if err != nil {
if errors.Is(err, uiutil.InterruptErr) {
logrus.Fatal("Interrupted by user")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fatal exits the process right away and should be reserved for unrecoverable errors. This feels more like a shortcut for regular control flow because we can't return an error.

source,
destination,
}
rsyncCmd := exec.CommandContext(ctx, "rsync", rsyncArgs...)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please create an issue for it if you don't include it in the PR.

@AkihiroSuda
Copy link
Member

@jandubois Can we merge this and release v2.1 alpha ?
FOSDEM is on this weekend

@jandubois
Copy link
Member

Can we merge this and release v2.1 alpha ? FOSDEM is on this weekend

Sorry, I didn't realize this. I thought 2.1 was planned for KubeCon.

I can't review until tomorrow, but I'm fine with you merging it if you and @unsuman both think it is fine. It will just be an alpha, after all.

if pager == "" {
pager = "less"
}
lessCmd := exec.CommandContext(ctx, pager)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't work when PAGER contains whitespaces

@AkihiroSuda
Copy link
Member

I thought 2.1 was planned for KubeCon.

Yes, the plan is unchanged.
For FOSDEM I just want an alpha.

@AkihiroSuda
Copy link
Member

CI failing

@jandubois
Copy link
Member

CI failing

Because the PR needs to be rebased on latest master. CI wants to run colima.bats which doesn't exist in this PR.

var sshExecForRsync *exec.Cmd
if syncHostWorkdir {
logrus.Infof("Syncing host current directory(%s) to guest instance...", hostCurrentDir)
sshExecForRsync = exec.CommandContext(ctx, sshExe.Exe, sshArgs...)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is missing the port and host arguments. It looks like it works if it connects via the ControlMaster socket, but if that is broken, it will not create its own connection:

rm ~/.lima/default/ssh.socklimactl shell --sync . --yes default pwd
INFO[0000] Syncing host current directory(/Users/jan/suse/lima/hack/bats) to guest instance...
FATA[0000] failed to create the synced workdir in guest instance: exit status 255

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the BATS test need to include variants that delete the ssh.sock to properly test the explicit SSH commands.

}

func executeSSHForRsync(ctx context.Context, sshCmd *exec.Cmd, command string) error {
sshRmCmd := exec.CommandContext(ctx, sshCmd.Path, append(sshCmd.Args, command)...)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
sshRmCmd := exec.CommandContext(ctx, sshCmd.Path, append(sshCmd.Args, command)...)
// Skip Args[0] (program name) to avoid duplication
sshRmCmd := exec.CommandContext(ctx, sshCmd.Path, append(sshCmd.Args[1:], command)...)

…g the host files

Signed-off-by: Ansuman Sahoo <anshumansahoo500@gmail.com>
Copy link
Member

@jandubois jandubois left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, LGTM

@jandubois jandubois merged commit 4508659 into lima-vm:master Jan 30, 2026
37 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add limactl shell --sync-host-workdir (prevents AI agents from breaking the host files)

3 participants